Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×

Advanced Transform --> Group via API

Hello, im trying to create a macro that generates a circle around a token, and then groups the token and the circle. I can do this via the UI, but i cant see how that grouping option is exposed to the API.  Is it exposed anyway in the API, so i can group the objects together? 
I was able to achieve this functionality by essentially deleting and recreating the ring around the token whenever the token moves. Sharing it here in case anyone wants to do something similar. You can achieve the same thing with Auras, but those don't support full transparent circles, and were a little more cumbersome than what I wanted to do. It allows you to show the range of a character's attack based on a 5e Character Sheet's ranged attack distance. /** * RangeRing — Delete and recreate version */ (() => { 'use strict'; const CFG = { defaultMode: 'long', stroke: '#ff0000', strokeWidth: 4, segments: 48, layer: 'objects', sendToBack: true, gmOnly: false, rangeAttrRegexes: [ /^repeating_attack_.*_atkrange$/i, /^repeating_attack_.*_range$/i, /^repeating_npcaction_.*_range$/i, /^repeating_npcaction_.*_rangereach$/i, /^ranged_range$/i, /^max_ranged_range$/i, /^range_max$/i, ], extractNumbers: (s) => (String(s).match(/\d+/g) || []) .map(n => parseInt(n, 10)) .filter(Number.isFinite), }; state.RangeRing = state.RangeRing || { rings: {} }; const getPageFeetPerUnit = (pageId) => { const page = getObj('page', pageId); const scale = page ? parseFloat(page.get('scale_number')) : 5; return Number.isFinite(scale) && scale > 0 ? scale : 5; }; const getPagePxPerUnit = () => 70; const tokenHalfWidthUnits = (token) => { const wUnits = (parseFloat(token.get('width')) || 70) / getPagePxPerUnit(); return wUnits / 2; }; const parseRangeFeet = (raw, mode) => { if (raw == null || raw === '') return 0; const nums = CFG.extractNumbers(raw); if (!nums.length) return 0; if (nums.length === 1) return nums[0]; return mode === 'short' ? nums[0] : nums[nums.length - 1]; }; const findMaxRangedFeetFromCharacter = (charId, mode) => { const attrs = findObjs({ _type: 'attribute', _characterid: charId }) || []; let maxFeet = 0; for (const a of attrs) { const name = a.get('name') || ''; if (!CFG.rangeAttrRegexes.some(re => re.test(name))) continue; const cur = a.get('current'); const feet = parseRangeFeet(cur, mode); if (feet > maxFeet) maxFeet = feet; } return maxFeet; }; const buildCirclePath = (radiusPx, segments) => { const r = radiusPx; const cx = r; const cy = r; const pts = []; for (let i = 0; i <= segments; i++) { const theta = (i / segments) * Math.PI * 2; const x = cx + r * Math.cos(theta); const y = cy + r * Math.sin(theta); pts.push([x, y]); } const path = []; path.push(['M', pts[0][0], pts[0][1]]); for (let i = 1; i < pts.length; i++) { path.push(['L', pts[i][0], pts[i][1]]); } return JSON.stringify(path); }; const createRing = (token, radiusUnits) => { const tokenId = token.id; const pageId = token.get('pageid'); const pxPerUnit = getPagePxPerUnit(); const radiusPx = radiusUnits * pxPerUnit; const sizePx = radiusPx * 2; const ringLayer = CFG.gmOnly ? 'gmlayer' : CFG.layer; const ringPath = buildCirclePath(radiusPx, CFG.segments); // Delete old ring if it exists const existing = state.RangeRing.rings[tokenId]; if (existing && existing.pathId) { const oldPath = getObj('path', existing.pathId); if (oldPath) oldPath.remove(); } // Create new ring const p = createObj('path', { pageid: pageId, layer: ringLayer, left: token.get('left'), top: token.get('top'), width: sizePx, height: sizePx, path: ringPath, stroke: CFG.stroke, stroke_width: CFG.strokeWidth, fill: 'transparent', controlledby: '', }); state.RangeRing.rings[tokenId] = { pathId: p.id, radiusUnits }; if (CFG.sendToBack) toBack(p); }; const removeRing = (tokenId) => { const rec = state.RangeRing.rings[tokenId]; if (!rec) return; const p = getObj('path', rec.pathId); if (p) p.remove(); delete state.RangeRing.rings[tokenId]; }; const parseManualRadius = (arg, pageFeetPerUnit) => { if (!arg) return null; const s = String(arg).trim().toLowerCase(); if (!s) return null; if (s.endsWith('u')) { const n = parseFloat(s.slice(0, -1)); return Number.isFinite(n) && n > 0 ? n : null; } if (s.endsWith('ft')) { const n = parseFloat(s.slice(0, -2)); if (!Number.isFinite(n) || n <= 0) return null; return n / pageFeetPerUnit; } const n = parseFloat(s); if (!Number.isFinite(n) || n <= 0) return null; return n / pageFeetPerUnit; }; on('chat:message', (msg) => { if (msg.type !== 'api') return; if (!msg.content || !msg.content.toLowerCase().startsWith('!range')) return; const parts = msg.content.split(/\s+/).slice(1); const selected = msg.selected || []; if (!selected.length) { sendChat('RangeRing', `/w gm Select one or more tokens first.`); return; } let mode = CFG.defaultMode; let stroke = CFG.stroke; let strokeWidth = CFG.strokeWidth; let segments = CFG.segments; let manualArg = null; for (let i = 0; i < parts.length; i++) { const p = parts[i]; if (p === '--short') mode = 'short'; else if (p === '--long') mode = 'long'; else if (p === '--stroke' && parts[i + 1]) { stroke = parts[++i]; } else if (p === '--width' && parts[i + 1]) { strokeWidth = parseInt(parts[++i], 10) || strokeWidth; } else if (p === '--segments' && parts[i + 1]) { segments = parseInt(parts[++i], 10) || segments; } else if (!p.startsWith('--') && !manualArg) manualArg = p; } CFG.stroke = stroke; CFG.strokeWidth = strokeWidth; CFG.segments = Math.max(12, Math.min(180, segments)); selected.forEach(sel => { const token = getObj('graphic', sel._id); if (!token) return; const tokenId = token.id; if (state.RangeRing.rings[tokenId]) { removeRing(tokenId); return; } const pageFeetPerUnit = getPageFeetPerUnit(token.get('pageid')); let radiusUnits = parseManualRadius(manualArg, pageFeetPerUnit); const edgeOffset = tokenHalfWidthUnits(token); if (radiusUnits != null) { radiusUnits = radiusUnits + edgeOffset; createRing(token, radiusUnits); return; } const charId = token.get('represents'); if (!charId) { sendChat('RangeRing', `/w gm Token "${token.get('name') || tokenId}" does not represent a character. Use !range 150ft or set represents.`); return; } const maxFeet = findMaxRangedFeetFromCharacter(charId, mode); if (!maxFeet || maxFeet <= 0) { sendChat('RangeRing', `/w gm No ranged attack ranges found for "${token.get('name') || 'token'}". Either add a matching attribute pattern or use !range 150ft / !range 3u.`); return; } radiusUnits = Math.ceil(maxFeet / pageFeetPerUnit) + edgeOffset; createRing(token, radiusUnits); }); }); // DELETE AND RECREATE on every move on('change:graphic', (obj, prev) => { const rec = state.RangeRing.rings[obj.id]; if (!rec) return; const leftChanged = obj.get('left') !== prev.left; const topChanged = obj.get('top') !== prev.top; const pageChanged = prev.pageid !== undefined && obj.get('pageid') !== prev.pageid; if (!leftChanged && !topChanged && !pageChanged) return; // Just recreate the ring at the new position createRing(obj, rec.radiusUnits); }); on('destroy:graphic', (obj) => { removeRing(obj.id); }); })();
1771367290
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
teamgaur said: I was able to achieve this functionality by essentially deleting and recreating the ring around the token whenever the token moves. Sharing it here in case anyone wants to do something similar. You can achieve the same thing with Auras, but those don't support full transparent circles, and were a little more cumbersome than what I wanted to do. In case it helps, auras support rgba. You can use the last pair of the definition to control opacity.
I wish the aura's colorpicker made it a little more obvious that you could control the alpha. I didn't know this, so thank you Keith :D